Skip to content

Chore/pin npm exactly - #10

Merged
Radiergummi merged 3 commits into
mainfrom
chore/pin-npm-exactly
Jul 30, 2026
Merged

Chore/pin npm exactly#10
Radiergummi merged 3 commits into
mainfrom
chore/pin-npm-exactly

Conversation

@Radiergummi

Copy link
Copy Markdown
Member

No description provided.

Radiergummi and others added 3 commits July 30, 2026 13:08
npm cannot attach a trusted publisher to a package that does not exist,
and offers no way to reserve a name, so the first publish must
authenticate with a token. The risk is not the bootstrap itself but that
the credential quietly becomes permanent.

So the workflow refuses the token path once the package exists on npmjs:
a release fails while NPM_TOKEN is still present. Cleanup stops being
something to remember and becomes something the pipeline enforces.

All four states are handled explicitly and were tested:

  token + package absent  -> publish, with a loud warning
  token + package exists  -> refuse, tell the operator to delete the secret
  no token + absent       -> refuse, explain why a token is needed
  no token + exists       -> OIDC, the steady state

Provenance is unaffected, which is the part worth knowing: --provenance
derives from the job's Sigstore identity via id-token, not from how npm
authenticates. v0.1.0 is therefore fully attested despite predating
trusted publishing on the package.

Dropped registry-url from setup-node. It writes an .npmrc line of
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}, which resolves to an
empty token when no secret is set and would have made npm attempt token
auth instead of falling back to OIDC -- breaking every release after the
first. The registry comes from the tarball's own publishConfig.

SECURITY.md documents the bootstrap as a four-step procedure with the
token deliberately weak: granular, @matchory-scoped, 7-day expiry, stored
as a release-environment secret rather than a repository one so the
existing approval and tag gates already apply to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW
Audited the workflow against npm's documented guidance and against npm's
own implementation. Three findings, two of them real defects.

1. The publish registry was not actually pinned

   publishConfig.registry does NOT beat an `@scope:registry` entry in an
   .npmrc, and libnpmpublish's ensureProvenanceGeneration validates the CI
   provider and the id-token but never the destination registry. A stray
   scope mapping could therefore have redirected a provenance-signed
   release to another registry, and npm would have signed it happily.

   Found empirically rather than by reading: `npm publish --dry-run` for
   this package resolved to npm.pkg.github.com, not npmjs, because a
   developer .npmrc maps the @matchory scope there. CI has no such file, so
   it would not have fired -- but "would not have fired" is not a control.

   --registry is now pinned on the publish and on the package-existence
   probe. That is strictly stronger than publishConfig or setup-node's
   registry-url, because nothing ambient can override it.

2. A prerelease would have been published as the default install

   The tag regex accepts prereleases, but --tag was hardcoded to latest, so
   v1.0.0-rc.1 would have become `latest` for every consumer. The dist-tag
   is now derived from the version: rc, beta, alpha, or next for an
   unrecognised identifier. GitHub releases are marked --prerelease to
   match. Tested across seven version shapes.

3. My stated reason for dropping setup-node's registry-url was wrong

   I claimed it writes an empty _authToken and breaks the OIDC fallback.
   npm's own documented workflow uses registry-url with no token at all, so
   that was an unverified assumption. It stays omitted, but for the correct
   reason: --registry on the command line cannot be overridden, whereas
   registry-url can.

Also verified the build/publish split rather than assuming it. Provenance
derives its subject from the tarball's own integrity digest and its build
metadata from GITHUB_* environment variables, so publishing a pre-packed
tarball generates provenance identically to publishing from a source
directory. npm's documented workflow does not split build from publish, so
this is a deliberate divergence, kept because it is what makes "publish
cannot substitute content" true and because it matches PyPA's
recommendation for PyPI. Recorded in SECURITY.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW
Verified that npm has no equivalent of PyPI's pending publishers: a
trusted publisher is configured per-package at
npmjs.com/package/<name>/access, which does not exist until something has
been published. The catch-22 is real, so something must be published
before OIDC can be used at all.

Crossing that gap with a deprecated 0.0.0 placeholder is better than
bootstrapping with a CI token, which is what was documented before:

  - no npm token ever enters this repository, at any point in its history
  - 0.1.0, the first version anyone installs, is published entirely
    through OIDC with provenance

The alternative would have put a short-lived token in a GitHub secret so
that 0.1.0 could be published from CI. That gets provenance on 0.1.0 but
accepts a credential in the repository. The placeholder trades a permanent
deprecated 0.0.0 for never holding a token, which is the better deal.

The placeholder is deliberately inert: a README and the licence, 1.3 kB,
three files. No real configuration ships without provenance, and it is
published under a `placeholder` dist-tag so it never becomes `latest`.

The token path stays in release.yml as a fallback for bootstrapping some
future package, and remains self-limiting: the workflow refuses to use a
token once the package exists.

Also documents the trap that `npm login`/`npm publish` need an explicit
--registry, because an @matchory:registry entry in a developer .npmrc
points the scope at GitHub Packages and beats publishConfig.registry.
Found while checking this: the npmjs token in the local .npmrc is expired,
so the placeholder has to be published after a fresh login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW
@Radiergummi Radiergummi self-assigned this Jul 30, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@Radiergummi
Radiergummi merged commit 7996f31 into main Jul 30, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant